为全球开发者提供的关于并发控制的综合指南。探索基于锁的同步、互斥锁、信号量、死锁和最佳实践。
掌握并发:深入探讨基于锁的同步
想象一下一间熙熙攘攘的专业厨房。多个厨师同时工作,都需要使用共享的食材储藏室。如果两个厨师同时试图抓取最后一罐稀有香料,谁会得到它?如果一个厨师正在更新食谱卡,而另一个厨师正在阅读它,导致出现半写半读,毫无意义的指令?这种厨房混乱是现代软件开发中核心挑战的完美类比:并发。
在当今多核处理器、分布式系统和高响应应用程序的世界中,并发性——程序的各个不同部分能够以无序或部分有序的方式执行,而不会影响最终结果——不是一种奢侈,而是一种必需品。它是快速Web服务器、流畅的用户界面和强大的数据处理管道背后的引擎。然而,这种力量伴随着巨大的复杂性。当多个线程或进程同时访问共享资源时,它们可能会相互干扰,导致数据损坏、行为不可预测以及关键系统故障。这就是并发控制发挥作用的地方。
本综合指南将探讨管理这种受控混乱的最基本和广泛使用的技术:基于锁的同步。我们将揭开锁的神秘面纱,探索它们的不同形式,规避其危险的陷阱,并建立一套全球最佳实践,用于编写健壮、安全和高效的并发代码。
什么是并发控制?
从本质上讲,并发控制是计算机科学中的一门学科,致力于管理对共享数据的同步操作。其主要目标是确保并发操作正确执行,彼此之间互不干扰,从而保持数据完整性和一致性。可以将其想象成厨房经理,他为厨师如何访问储藏室设定规则,以防止溢出、混淆和浪费食材。
在数据库领域,并发控制对于维护ACID属性(原子性、一致性、隔离性、持久性)至关重要,特别是隔离性。隔离性确保事务的并发执行导致一个系统状态,该状态与事务被串行执行(一个接一个)所获得的状态相同。
实现并发控制有两种主要的哲学:
- 乐观并发控制:这种方法假设冲突很少见。它允许操作在没有任何前期检查的情况下进行。在提交更改之前,系统会验证是否有其他操作在此期间修改了数据。如果检测到冲突,则通常会回滚该操作并重试。这是一种“请求原谅而不是请求许可”的策略。
- 悲观并发控制:这种方法假设冲突很可能发生。它强制操作在访问资源之前获取该资源的锁,从而防止其他操作的干扰。这是一种“请求许可而不是请求原谅”的策略。
本文专门关注悲观方法,这是基于锁的同步的基础。
核心问题:竞争条件
在我们欣赏解决方案之前,我们必须完全理解问题。并发编程中最常见和最隐蔽的错误是竞争条件。当系统的行为取决于不可预测的、不可控制的事件的顺序或时间时,例如操作系统对线程的调度时,就会发生竞争条件。
让我们考虑一个经典的例子:一个共享的银行账户。假设一个账户的余额为1000美元,并且两个并发线程尝试分别存入100美元。
以下是存款的简化操作序列:
- 从内存中读取当前余额。
- 将存款金额添加到该值中。
- 将新值写回内存。
正确的串行执行将导致最终余额为1200美元。但在并发场景中会发生什么?
操作的潜在交错:
- 线程 A:读取余额(1000 美元)。
- 上下文切换:操作系统暂停线程 A 并运行线程 B。
- 线程 B:读取余额(仍然是 1000 美元)。
- 线程 B:计算其新余额(1000 美元 + 100 美元 = 1100 美元)。
- 线程 B:将新余额(1100 美元)写回内存。
- 上下文切换:操作系统恢复线程 A。
- 线程 A:根据它先前读取的值计算其新余额(1000 美元 + 100 美元 = 1100 美元)。
- 线程 A:将新余额(1100 美元)写回内存。
最终余额为1100美元,而不是预期的1200美元。由于竞争条件,100美元的存款消失了。访问共享资源(账户余额)的代码块称为关键部分。为了防止竞争条件,我们必须确保在任何给定时间只有一个线程可以在关键部分内执行。这一原则被称为互斥。
介绍基于锁的同步
基于锁的同步是强制互斥的主要机制。锁(也称为互斥锁)是一种同步原语,充当关键部分的保护器。
将锁比作单人浴室的钥匙非常合适。浴室是关键部分,钥匙是锁。许多人(线程)可能正在外面等待,但只有拿着钥匙的人才能进入。当他们完成后,他们离开并归还钥匙,允许排在下一个人接钥匙并进入。
锁支持两个基本操作:
- 获取(或锁定):线程在进入关键部分之前调用此操作。如果锁可用,则线程获取它并继续。如果锁已被另一个线程持有,则调用线程将被阻塞(或“休眠”),直到锁被释放。
- 释放(或解锁):线程在完成关键部分执行后调用此操作。这使得锁可供其他等待线程获取。
通过用锁封装我们的银行账户逻辑,我们可以保证其正确性:
acquire_lock(account_lock);
// --- 关键部分开始 ---
balance = read_balance();
new_balance = balance + amount;
write_balance(new_balance);
// --- 关键部分结束 ---
release_lock(account_lock);
现在,如果线程 A 首先获取锁,线程 B 将被迫等待,直到线程 A 完成所有三个步骤并释放锁。操作不再交错,并且消除了竞争条件。
锁的类型:程序员的工具包
虽然锁的基本概念很简单,但不同的场景需要不同类型的锁定机制。了解可用锁的工具包对于构建高效且正确的并发系统至关重要。
互斥(互斥)锁
互斥锁是最简单、最常见的锁类型。它是一个二进制锁,这意味着它只有两种状态:锁定或未锁定。它旨在强制执行严格的互斥,确保在任何时候只有一个线程可以拥有该锁。
- 所有权:大多数互斥锁实现的一个关键特征是所有权。获取互斥锁的线程是唯一允许释放它的线程。这可以防止一个线程无意中(或恶意地)解锁另一个线程正在使用的关键部分。
- 用例:互斥锁是保护短小的、简单的关键部分(如更新共享变量或修改数据结构)的默认选择。
信号量
信号量是一种更通用的同步原语,由荷兰计算机科学家Edsger W. Dijkstra发明。与互斥锁不同,信号量维护一个非负整数值的计数器。
它支持两个原子操作:
- wait()(或 P 操作):减少信号量的计数器。如果计数器变为负数,则线程阻塞,直到计数器大于或等于零。
- signal()(或 V 操作):增加信号量的计数器。如果信号量上有任何线程被阻塞,则其中一个将被解除阻塞。
信号量主要有两种类型:
- 二进制信号量:计数器初始化为 1。它只能是 0 或 1,使其在功能上等同于互斥锁。
- 计数信号量:计数器可以初始化为任何整数 N > 1。这允许最多 N 个线程并发访问资源。它用于控制对有限资源池的访问。
示例:想象一个Web应用程序,它有一个连接池,可以处理最多 10 个并发数据库连接。初始化为 10 的计数信号量可以完美地管理此问题。每个线程都必须在获取连接之前对信号量执行`wait()`。第11个线程将被阻塞,直到前10个线程之一完成其数据库工作并对信号量执行`signal()`,从而将连接返回到池中。
读写锁(共享/独占锁)
并发系统中一个常见的模式是读取数据的频率远高于写入数据的频率。在这种情况下使用简单的互斥锁效率低下,因为它会阻止多个线程同时读取数据,即使读取是一个安全的、非修改操作。
读写锁通过提供两种锁定模式来解决此问题:
- 共享(读)锁:只要没有线程持有写锁,多个线程就可以同时获取读锁。这允许高并发读取。
- 独占(写)锁:一次只有一个线程可以获取写锁。当一个线程持有写锁时,所有其他线程(读线程和写线程)都会被阻塞。
类比是共享库中的文档。许多人可以同时阅读该文档的副本(共享读锁)。但是,如果有人想编辑文档,他们必须独占地签出它,并且在他们完成之前,其他任何人都不能阅读或编辑它(独占写锁)。
递归锁(可重入锁)
如果已经持有互斥锁的线程再次尝试获取它会发生什么情况?对于标准互斥锁,这将导致立即死锁——线程将永远等待自己释放锁。递归锁(或可重入锁)旨在解决此问题。
递归锁允许同一线程多次获取相同的锁。它维护一个内部所有权计数器。仅当拥有线程调用`release()`的次数与其调用`acquire()`的次数相同时,该锁才会被完全释放。这在需要在其执行期间保护共享资源的递归函数中特别有用。
锁的危险:常见陷阱
虽然锁很强大,但它们是一把双刃剑。不正确地使用锁会导致比简单的竞争条件更难诊断和修复的错误。这些错误包括死锁、活锁和性能瓶颈。
死锁
死锁是并发编程中最令人恐惧的场景。当两个或多个线程被无限期地阻塞时,每个线程都在等待由同一组中的另一个线程持有的资源时,就会发生死锁。
考虑一个包含两个线程(线程 1、线程 2)和两个锁(锁 A、锁 B)的简单场景:
- 线程 1 获取锁 A。
- 线程 2 获取锁 B。
- 线程 1 现在尝试获取锁 B,但它被线程 2 持有,因此线程 1 被阻塞。
- 线程 2 现在尝试获取锁 A,但它被线程 1 持有,因此线程 2 被阻塞。
现在两个线程都陷入永久等待状态。应用程序停止运行。这种情况源于四个必要条件(Coffman 条件):
- 互斥:资源(锁)不能共享。
- 持有和等待:一个线程在等待另一个资源时至少持有一个资源。
- 不可抢占:资源无法从持有它的线程中强制获取。
- 循环等待:存在两个或多个线程的链,其中每个线程都在等待由链中下一个线程持有的资源。
防止死锁涉及打破这四个条件中的至少一个。最常见的策略是通过对锁获取强制执行严格的全局顺序来打破循环等待条件。
活锁
活锁是死锁更微妙的表亲。在活锁中,线程没有被阻塞——它们正在积极运行——但它们没有取得任何进展。它们陷入相互响应彼此状态变化的循环中,而没有完成任何有用的工作。
经典的类比是两个人试图在狭窄的走廊里互相通行。他们都试图保持礼貌并向左移动,但最终相互阻塞。然后他们都向右移动,再次相互阻塞。他们正在积极地移动,但没有沿着走廊前进。在软件中,这可能发生在设计不佳的死锁恢复机制中,其中线程反复退回并重试,只是再次发生冲突。
饥饿
当线程被永久拒绝访问必要的资源,即使该资源变得可用时,就会发生饥饿。这可能发生在调度算法不“公平”的系统中。例如,如果锁定机制总是授予高优先级线程访问权限,那么如果存在持续的高优先级竞争者流,则低优先级线程可能永远没有机会运行。
性能开销
锁不是免费的。它们以多种方式引入性能开销:
- 获取/释放成本:获取和释放锁的行为涉及原子操作和内存围栏,这比普通指令在计算上更昂贵。
- 争用:当多个线程频繁争用同一锁时,系统会花费大量时间进行上下文切换和调度线程,而不是进行有效的工作。高争用有效地串行化了执行,从而违背了并行性的目的。
基于锁的同步的最佳实践
使用锁编写正确且高效的并发代码需要纪律并遵守一组最佳实践。这些原则普遍适用,与编程语言或平台无关。
1. 保持关键部分较小
锁应保持最短的可能持续时间。您的关键部分应仅包含绝对必须防止并发访问的代码。任何非关键操作(如 I/O、不涉及共享状态的复杂计算)都应在锁定区域之外执行。持有锁的时间越长,争用的机会就越大,并且您阻塞其他线程的可能性就越大。
2. 选择正确的锁粒度
锁粒度是指单个锁保护的数据量。
- 粗粒度锁定:使用单个锁来保护大型数据结构或整个子系统。这更容易实现和推理,但会导致高争用,因为对数据不同部分的无关操作都由同一锁串行化。
- 细粒度锁定:使用多个锁来保护数据结构的不同、独立的部分。例如,对于整个哈希表,您可以为每个桶使用一个单独的锁,而不是使用一个锁。这更复杂,但可以通过允许更多的真正并行性来显着提高性能。
它们之间的选择是简单性和性能之间的权衡。从较粗粒度的锁开始,并且只有在性能分析表明锁争用是瓶颈时,才切换到更细粒度的锁。
3. 始终释放您的锁
未能释放锁是一个灾难性的错误,很可能导致系统停止运行。此错误的一个常见原因是,在关键部分内发生异常或提早返回。为了防止这种情况,始终使用保证清理的语言结构,例如 Java 或 C# 中的try...finally块,或 C++ 中带有作用域锁的 RAII(资源获取即初始化)模式。
示例(使用 try-finally 的伪代码):
my_lock.acquire();
try {
// 可能抛出异常的关键部分代码
} finally {
my_lock.release(); // 保证执行
}
4. 遵循严格的锁顺序
为了防止死锁,最有效的策略是打破循环等待条件。为获取多个锁建立严格的、全局的、任意的顺序。如果线程需要同时持有锁 A 和锁 B,则它必须始终在获取锁 B 之前获取锁 A。此简单规则使循环等待成为不可能。
5. 考虑锁定替代方案
虽然锁是基础,但它们并不是并发控制的唯一解决方案。对于高性能系统,值得探索高级技术:
- 无锁数据结构:这些是使用低级原子硬件指令(如比较和交换)设计的数据结构,允许并发访问,而无需完全使用锁。它们很难正确实现,但在高争用下可以提供卓越的性能。
- 不可变数据:如果数据在创建后从未被修改,则可以在线程之间自由共享,而无需任何同步。这是函数式编程的核心原则,也是简化并发设计的日益流行的方式。
- 软件事务内存 (STM):一种更高级别的抽象,允许开发人员在内存中定义原子事务,就像在数据库中一样。STM 系统在幕后处理复杂的同步细节。
结论
基于锁的同步是并发编程的基石。它提供了一种强大而直接的方式来保护共享资源并防止数据损坏。从简单的互斥锁到更细微的读写锁,这些原语是任何构建多线程应用程序的开发人员的基本工具。
但是,这种力量需要责任。深入了解潜在的陷阱——死锁、活锁和性能下降——并非可选。通过遵守最佳实践,例如最大限度地减少关键部分大小、选择适当的锁粒度并执行严格的锁顺序,您可以利用并发的力量,同时避免其危险。
掌握并发是一个旅程。它需要仔细的设计、严格的测试以及始终意识到线程并行运行时可能发生的复杂交互的思维方式。通过掌握锁定的艺术,您可以迈出构建不仅快速且响应迅速,而且健壮、可靠且正确的软件的关键一步。